FollowList.tsx 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. 'use client';
  2. import { useCallback, useState } from 'react';
  3. import { fetchApi } from '@/lib/utils/client';
  4. import { FollowMember, FollowListResponse } from '@/types/account/follow';
  5. import FollowListItem from './FollowListItem';
  6. type Mode = 'followers' | 'following';
  7. type Props = {
  8. mode: Mode;
  9. memberSID: string;
  10. initialList: FollowMember[];
  11. initialTotal: number;
  12. initialPage?: number;
  13. perPage?: number;
  14. };
  15. export default function FollowList({
  16. mode,
  17. memberSID,
  18. initialList,
  19. initialTotal,
  20. initialPage = 1,
  21. perPage = 20
  22. }: Props) {
  23. const [list, setList] = useState<FollowMember[]>(initialList);
  24. const [total, setTotal] = useState<number>(initialTotal);
  25. const [nextPage, setNextPage] = useState<number>(initialPage + 1);
  26. const [loading, setLoading] = useState<boolean>(false);
  27. const [errorMessage, setErrorMessage] = useState<string|null>(null);
  28. const hasMore = list.length < total;
  29. const endpointPath = mode === 'followers' ? 'followers' : 'following';
  30. const loadMore = useCallback(async () => {
  31. if (loading || !hasMore) {
  32. return;
  33. }
  34. setLoading(true);
  35. setErrorMessage(null);
  36. try {
  37. const url = `/api/member/${encodeURIComponent(memberSID)}/${endpointPath}?page=${nextPage}&perPage=${perPage}`;
  38. const res = await fetchApi<FollowListResponse>(url, { method: 'GET', silent: true });
  39. if (!res.success || !res.data) {
  40. setErrorMessage(res.message ?? '목록을 불러오지 못했습니다.');
  41. return;
  42. }
  43. const incoming = res.data.list;
  44. setList((prev) => {
  45. const seen = new Set(prev.map((m) => m.sid));
  46. const dedup = incoming.filter((m) => !seen.has(m.sid));
  47. return [...prev, ...dedup];
  48. });
  49. setTotal(res.data.total);
  50. setNextPage((p) => p + 1);
  51. } catch (err) {
  52. console.error('Follow list loadMore failed:', err);
  53. setErrorMessage(err instanceof Error ? err.message : '목록을 불러오지 못했습니다.');
  54. } finally {
  55. setLoading(false);
  56. }
  57. }, [loading, hasMore, memberSID, endpointPath, nextPage, perPage]);
  58. if (list.length === 0) {
  59. const emptyText = mode === 'followers'
  60. ? '아직 팔로워가 없습니다.'
  61. : '아직 팔로우하는 회원이 없습니다.';
  62. return <p className="user-profile__empty">{emptyText}</p>;
  63. }
  64. const sectionLabel = mode === 'followers' ? '팔로워 목록' : '팔로우 목록';
  65. return (
  66. <section className="user-profile__follow-section" aria-label={sectionLabel}>
  67. <p className="user-profile__follow-total" aria-live="polite">
  68. 전체 {total.toLocaleString()}명
  69. </p>
  70. <ul className="user-profile__follow-list" role="list">
  71. {list.map((member) => (
  72. <FollowListItem key={member.sid} member={member} />
  73. ))}
  74. </ul>
  75. {errorMessage && (
  76. <p className="user-profile__follow-error" role="alert">{errorMessage}</p>
  77. )}
  78. {hasMore && (
  79. <button
  80. type="button"
  81. className="user-profile__follow-more-btn"
  82. onClick={loadMore}
  83. disabled={loading}
  84. aria-label={loading ? '불러오는 중' : '다음 목록 불러오기'}
  85. >
  86. {loading ? '불러오는 중...' : '더 보기'}
  87. </button>
  88. )}
  89. </section>
  90. );
  91. }